查看原文
其他

JAVA多线程并发编程-避坑指南

肖朋伟 京东云开发者
2024-08-25



一、前言


开发过程中,多线程的应用场景可谓十分广泛,可以充分利用服务器资源,提高程序处理速度。我们通常也会使用池化技术,去避免频繁创建和销毁线程。

本篇旨在基于编码规范、工作中积累的研发经验等,整理在多线程开发的过程中需要注意的部分,比如不考虑线程池参数、线程安全、死锁等问题,将会存在潜在极大的风险。并且对其进行根因分析,避免每天踩一坑,坑坑不一样。



二、多线程并发场景有哪些坑?


1.“不正确的创建”线程池

常规来说,线程资源必须通过线程池提供,不允许在应用中自行显式创建线程,京东 JAVA 代码规范也明确表示 “线程资源必须通过线程池提供,不允许在应用中自行显式创建线程”,但是创建线程池的方式也有很多种,不能滥用。

常见创建线程池方式如:通过 JDK 提供的 ThreadPoolExecutor、ScheduledThreadPoolExecutor 以及 JDK 7 开始引入的 ForkJoinPool 创建,还有更方便的 Executors 类以及 spring 提供的 ThreadPoolTaskExecutor 等。其关系如下图:

Executors的newFixedThreadPool、newScheduledThreadPool、newSingleThreadExecutor、newCachedThreadPool 方法在底层都是用的 ThreadPoolExecutor 实现的。虽然更加方便,但也增加了风险,如果不清楚其相关实现,在特定场景可能导致很严重的问题,所以开发规范中,会严格禁用此类用法。它们的弊端如下:

1)FixedThreadPool 和 SingleThreadPool 允许的请求任务队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

2)CachedThreadPool 和 ScheduledThreadPool 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

2.线程池“参数设置不合理”

上面提到了任务队列和最大线程数的重要性,下面通过 ThreadPoolExecutor 介绍线程池其他核心参数,都应该根据场景合理配置,详细如下:

参数

含义

描述

corePoolSize

核心线程数

线程池中要保留的线程数,即使它们处于空闲状态,除非设置了 allowCoreThreadTimeOut

maximumPoolSize

最大线程数

线程池中允许的最大线程数

keepAliveTime

保持存活时间

当线程数大于核心时,这是多余的空闲线程在终止前等待新任务的最长时间

unit

时间单位

keepAliveTime 参数的时间单位

workQueue

任务队列

用于在执行任务之前保存任务的队列。此队列将仅保存 execute方 法提交的 Runnable 任务

threadFactory

线程工厂

执行器创建新线程时要使用的工厂

handler

拒绝策略

当由于达到最大线程数和队列容量而导致执行受阻时使用的处理程序

注意在设置任务队列时,需要考虑有界和无界队列,使用有界队列时,注意线程池满了后,被拒绝的任务如何处理。使用无界队列时,需要注意如果任务的提交速度大于线程池的处理速度,可能会导致内存溢出。

对于拒绝策略,首先要明确的一点是“拒绝策略的执行线程是提交任务的线程,而不是子线程”。JDK 的 ThreadPoolExecutor 提供了 4 种拒绝策略:

内部类

拒绝策略

描述

AbortPolicy

中止策略

当触发拒绝策略时,直接抛出拒绝执行的 RejectedExecutionException 异常,中止策略的意思也就是打断当前执行流程。

CallerRunsPolicy

调用者运行策略

当触发拒绝策略时,只要线程池没有关闭,就由提交任务的当前线程处理。

DiscardOldestPolicy

弃老策略

如果线程池未关闭,就弹出队列头部的元素,然后尝试执行。

DiscardPolicy

丢弃策略

直接静悄悄的丢弃这个任务,不触发任何动作。

对于自定义拒绝策略,不同场景应选择相应的拒绝策略,抛开应用场景讲技术会显得苍白,大家可以参考常见技术框架的处理方式:

常见场景

描述

Dubbo

Dubbo 中的线程拒绝策略 AbortPolicyWithReport,输出当前线程堆栈详情等。

JSF

JSF 的 BusinessPool 中 RejectedExecutionHandler 设置钩子函数,允许业务系统去扩展实现。

Netty

Netty 中的线程池拒绝策略 NewThreadRunsPolicy,创建一个新的线程去执行。

ActiveMq

ActiveMq 中的线程池拒绝策略,通过队列的带有超时时间的 offer 方法,比如:executor.getQueue().offer(r, 60, TimeUnit.SECONDS); 要注意拒绝策略的执行线程是主线程,offer 函数也是会阻塞线程的,要慎用。

其他

以及我们内部常用的,可以进行 UMP 业务报警之后,再使用调用者执行、抛出异常等策略。

相关拓展:

相信大多数京东开发者都遇到过,JSF 依赖服务触发拒绝策略的现象,即抛出线程拒绝异常。这是当 Provider的 业务线程池满了,无可用线程池的时候,会返回一个异常给 Consumer,告知 Consumer 该 Provider 线程池已耗尽。如图:

当然这种异常场景,根本原因并非线程池配置不合理,应该关注服务提供方性能瓶颈,关于线程池的配置,其实没用一个统一或者可推荐的配置可以套用。对于 JSF 业务线程池,默认使用的是伸缩无队列线程池,其也提供了配置方式。

3.局部线程池“使用后不销毁回收”

线程会消耗宝贵的系统资源,比如内存等,所以是很不推荐使用局部线程池(未预先创建的线程池,用完就可以销毁,下次用时还会创建)的;但是如果某些特殊场景确实使用了局部线程池,那么应该在用完后,主动销毁。主动销毁线程池主要有两种方式:

方法

描述

shutdown

“温柔”地关闭线程池。不接受新任务,但是在关闭前会将之前提交的任务处理完毕。

shutdownNow

“粗暴”地关闭线程池,也就是直接关闭线程池,通过 Thread#interrupt() 方法终止所有线程,不会等待之前提交的任务执行完毕。但是会返回队列中未处理的任务。

为了更深入地理解两个问题:

1)到底是否所有局部创建的线程池都需要主动销毁?

2)为什么 Dubbo 中的线程拒绝策略 AbortPolicyWithReport 使用了 Executors.newSingleThreadExecutor(),并且没有主动销毁动作?

我们需要从 GC 角度进行分析。要知道对象什么时候死亡,我们需要先知道 JVM 的 GC 是如何判断对象是可以回收的。JAVA 是通过可达性算法来来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

对于线程池而言,在 ThreadPoolExecutor 类中具有非静态内部类 Worker,用于表示当前线程池中的线程,因为非静态内部类对象具有外部包装类对象的引用,所以当存在线程 Worker 对象时,线程池不会被 GC 回收。也就是说,线程池没有引用,且线程池内没有存活线程时,才是可以被 GC 回收的。应注意的是线程池的核心线程默认是一直存活的,除非核心线程数为 0 或者设置了 allowCoreThreadTimeOut 允许核心消除空闲时销毁。

我们对 Executors 创建的三种线程池进行比较:

线程池类型

CachedThreadPool

FixedThreadPool

SingleThreadExecutor

核心线程数

0

nThreads(用户设定)

1

最大线程数

Integer.MAX_VALUE

nThreads(用户设定)

1

非核心线程存活时间

60s

无非核心线程

无非核心线程

等待队列最大长度

0(这里用的是 SynchronousQueue,一个不存储元素的神奇的阻塞队列)

Integer.MAX_VALUE

Integer.MAX_VALUE

特点

提交任务优先复用空闲线程,没有空闲线程则创建新线程

固定线程数,等待运行的任务均放入等待队列

有且仅有一个线程在运行,等待运行任务放入等待队列,可保证任务运行顺序与提交顺序一致

三种类型的线程池与 GC 关系:

1)CachedThreadPool:没有核心线程,且线程具有超时时间,可见在其引用消失后,等待任务运行结束且所有线程空闲回收后,GC开始回收此线程池对象;

2)FixedThreadPool:核心线程数及最大线程数均为 nThreads,并且在默认 allowCoreThreadTimeOut 为 false 的情况下,其引用消失后,核心线程即使空闲也不会被回收,故 GC 不会回收该线程池;

3)SingleThreadExecutor:在创建时实际返回的是 FinalizableDelegatedExecutorService 类的对象,该类重新了 finalize() 函数执行线程池的销毁,该对象持有 ThreadPoolExecutor 对象的引用,但 ThreadPoolExecutor 对象并不引用 FinalizableDelegatedExecutorService 对象,这使得在 FinalizableDelegatedExecutorService 对象的外部引用消失后,GC 将会对其进行回收,触发 finalize 函数,而该函数仅仅简单的调用 shutdown 函数关闭线程,在所有当前的任务执行完成后,回收线程池中线程,则 GC 可回收线程池对象。

所以结论是:CachedThreadPool 及 SingleThreadExecutor 的对象在不显式销毁时,且其对象引用消失的情况下,可以被 GC 回收;FixedThreadPool 对象在不显式销毁,且其对象引用消失的情况下不会被 GC 回收,会出现内存泄露。因此无论使用什么线程池,使用完毕后均调用 shutdown 是一个较为安全的编程习惯。

4.线程池处理“刚启动时效率低”

默认情况下,即使是核心线程也只能在新任务到达时才创建和启动。对于 ThreadPoolExecutor 线程池可以使用 prestartCoreThread(启动一个核心线程)或 prestartAllCoreThreads(启动全部核心线程)方法来提前启动核心线程。

5.“不合理的使用共享线程池”

提交异步任务,不指定线程池,存在最主要问题是非核心业务占用线程资源,可能会导致核心业务受影响。因为公共线程池的最大线程数、队列大小、拒绝策略都比较保守,可能引发各种问题,常见场景如下:

1)CompleteFuture 提交异步任务,不指定线程池。

CompleteFuture 的 supplyAsync 等以 *Async 为结尾的方法,会使用多线程异步执行。可以注意到的是,它也允许我们不携带多线程提交任务的执行线程池参数,这个时候是默认使用的 ForkJoinPool.commonPool()。

ForkJoinPool 最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep() 等会造成线程长时间阻塞的情况时,最好配合使用 ManagedBlocker。ForkJoinPool 默认线程数取决于 parallelism 参数为:CPU 处理器核数-1,也允许通修改 Java 系统属性 "java.util.concurrent.ForkJoinPool.common.parallelism" 进行自定义配置。

2)JDK 8 引入的集合框架的并行流 Parallel Stream。

Parallel Stream 也是使用的 ForkJoinPool.commonPool(),但有一点区别是:Parallel Stream 的主线程 (提交任务的线程)是会去参与处理的;比如 8 核心的机器执行 Parallel Stream 是有 8 个线程,而 CompleteFuture 提交的任务只有 7 个线程处理。不建议使用是一致的。

3)@Async 提交异步任务,不指定线程池。

SpringBoot 2.1.9 之前版本使用 @Async 不指定 Executor 会使用 SimpleAsyncTaskExecutor,该线程池默认来一个任务创建一个线程,若系统中不断的创建线程,最终会导致系统占用内存过高,引发 OutOfMemoryErro r错误。SpringBoot 2.1.0 之后版本引入了 TaskExecutionAutoConfiguration,其使用 ThreadPoolTaskExecutor 作为 默认 Executor,通过 TaskExecutionProperties.Pool 可以看到其配置默认核心线程数:8,最大线程数:Integet.MAX_VALUE,队列容量是:Integet.MAX_VALUE,空闲线程保留时间:60s,线程池拒绝策略:AbortPolicy。

虽然可以通实现 AsyncConfigurer 接口等方式,自行配置线程池参数,但仍不建议使用公共线程池。

6.主线程“等待时间不合理”

1)应尽量避免使用 CompletableFuture.join(),Future.get() 这类不带有超时时间的阻塞主线程操作。

2)for 循环使用 future.get(long timeout, TimeUnit unit)。此方法允许我们去设置超时时间,但是如果主线程串行获取的话,下一个 future.get 方法的超时时间,是从第一个 get() 结束后开始计算的,所以会导致超时时间不合理。

7.提交任务“不考虑子线程超时时间”

1)主线程 Future.get 虽然超时,但是子线程依然在执行?

比如当通过 ExecutorService 提交一个 Callable 任务的时候,会返回一个 Future 对象,Future 的 get(long timeout, TimeUnit unit) 方法时,如果出现超时,则会抛出 java.util.concurrent.TimeoutException;但是,此时 Future 实例所在的线程并没有中断执行,只是主线程不等待了,也就是当前线程的 status 依然是 NEW 值为 0 的状态,所以当大量超时,可能就会将线程池打满。

提到中断子线程,会想到 future.cancel(true) 。那么我们真的可以中断子线程吗?首先 Java 无法直接其他线程的,如果非要实现此功能,也只能通过 interrupt 设置标志位,子线程执行到中间环节去查看标志位,识别到中断后做后续处理。理解一个关键点,interrupt() 方法仅仅是改变一个标志位的值而已,和线程的状态并没有必然的联系。

2)子线程的任务都应该有一个合理的超时时间。

比如子线程调用 JSF/ HTTP 接口等,一定要检查超时时间配置是否合理。

8.并发执行“线程不安全”操作

多线程操作同一对象应考虑线程安全性。常见场景比如 HashMap 应该换成 ConcurrentHashMap;StringBuilder 应该换成 StringBuffer 等。

9.“不考虑线程变量”的传递

提交到线程池执行的异步任务,切换了线程,子线程在执行时,获取不到主线程变量中存储的信息,常见场景如下:

1)类似 BU 等,为了减少参数透传,可能存在了 ThreadLocal 里面;

2)客户的登录状态,LoginContext 等信息,一般是线程变量;

如果解决此问题,可以参考 Transmittable-Thread-Local 中间件提供的解决方案等。

10.并发会“增大出现死锁的可能性”

多线程不只是程序中提交到线程池执行,比如打到同一容器的 http 请求本身就是多线程,任何多线程操作都有死锁风险。使用业务线程池的并发操作需要更加注意,因为更容易暴露出来“死锁”这个问题。

比如 Mysql 事务隔离级别为 RR 时,间隙锁可能导致死锁问题。间隙锁是 Innodb 在可重复读提交下为了解决幻读问题时引入的锁机制,在执行 update、delete、select ... for update 等语句时,存在以下加间隙锁情况:

1)有索引,当更新的数据存在时,只会锁定当前记录;更新的不存在时,间隙锁会向左找第一个比当前索引值小的值,向右找第一个比当前索引值大的值(没有比当前索引值大的数据时,是 supremum pseudo-record,可以理解为锁到无穷大)。

2)无索引,全表扫描,如果更新的数据不存在,则会根据主键索引对所有间隙加锁。

当并发执行数据库事务(事务内先更新,后新增操作),当更新的数据不存在时,会加间隙锁,然后执行新增数据需要其他事务释放在此区间的间隙锁,则可能导致死锁产生;如果是全表扫描,问题可能更严重。

11.“不考虑请求过载”

最后,我们设置了合理的参数,也注意优化了各种场景问题,终于可以大胆使用多线程了。也一定要考虑对下游带来的影响,比如数据库请求并发量增长,占用 JDBC 数据库连接、给依赖 RPC 服务带来性能压力等。

参考资料:

[1] http://www.kailing.pub/article/index/arcid/255.html

[2]https://blog.csdn.net/sinat_15946141/article/details/107951917

[3]https://segmentfault.com/a/1190000016461183?utm_source=tag-newest

[4]https://blog.csdn.net/v123411739/article/details/106609583/

[5]https://blog.csdn.net/whzhaochao/article/details/126032116

[6]https://www.kuangstudy.com/bbs/1407940516238090242


-end-

继续滑动看下一个
京东云开发者
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存